iT邦幫忙

2024 iThome 鐵人賽

DAY 16
1
自我挑戰組

從 Python 開發者的角度學習 Rust —— 從語法基礎到實戰應用系列 第 16

[Day 16] 泛型與特徵物件:提升 Rust 代碼的彈性與重用性

  • 分享至 

  • xImage
  •  

在之前的文章中,我們探討了 Rust 的結構體和元組如何幫助開發者組織和管理數據。今天,我們將深入探討泛型(Generics)與特徵物件(Trait Objects),了解它們如何讓 Rust 的代碼更加靈活和可重用。這些概念能幫助你在 Rust 中寫出更強大且易於維護的程式碼,特別適合從 Python 轉向 Rust 的開發者。


一、什麼是泛型?

泛型的核心概念是:你可以寫出能處理多種資料類型的代碼,而不用為每個不同的類型重複寫代碼。 它就像是萬能模板,可以接受各種不同的數據類型。在 Python 當中,我們不需要為函數或者物件做任何參數類型的定義就可以直接使用,這是所謂的動態類型:函數或物件不用事先指定參數的具體類型,而是根據實際傳入的內容來自動判斷操作。但前面的文章都有提過了, Rust 在資料類型定義上相當嚴謹,甚至對於熟悉 Python 的開發者而言,會感覺有點呆版,因此為了解決這個問題,泛型就是提供了這種靈活性的開發方式,使程式避免了因為錯誤而導致的程式問題,又可以使用動態類型去開發。

泛型就像你在廚房裡用一把可以切任何食材的刀,不管是切肉、切菜,還是切麵包,你都不需要為每一種食材準備不同的刀子。

1. 泛型的基礎

泛型可以用在函數、結構體、枚舉、方法等多種場景。在 Day 7 的文章 中,我們提到泛型函數讓我們可以處理不同的資料類型而不必重複撰寫代碼。以下是最基本的泛型函數範例:

// 定義一個使用泛型的函數,接受任何類型的參數並回傳
fn display<T>(item: T) {
    println!("{:?}", item);
}

fn main() {
    display(42);          // 使用整數
    display("Hello");     // 使用字串
}

在這段程式碼中,<T> 就是泛型。這個 T 可以代表任何類型,例如整數、字串等。這樣我們就不需要為每個類型寫一個新的 display 函數,而是一次搞定所有情況。

泛型函數的應用模板

對於較不熟悉於實際應用的開法者而言,泛型的使用有一個通用的模板或公式,讓我們可以輕鬆地在代碼中實現它們。以下是泛型應用的基本模板:

// 範例模板:使用泛型的結構
fn function_name<T>(parameter: T) -> T {
    // 函數邏輯
    parameter
}

這個模板拆解後的說明如下:

  • fn:宣告函數的關鍵字。
  • function_name<T>function_name 是函數名稱,<T> 宣告這個函數使用泛型 T。
  • parameter: Tparameter 是函數的參數,T 是泛型,代表這個參數的類型是可以變動的。
  • -> T:這邊是假設返回值與參數的類型相同,這個符號表示函數的返回值是 T 類型,與參數的類型一致。
  • parameter:函數的邏輯內容,可以是對參數的處理、計算等操作。

當然我們會遇到參數與返回值不同的情況,那我們就可以使用fn function_name<T, U>(parameter: T) -> U {}來表示,其中U代表另外一種資料類型,但也需要另外被定義。

泛型 Rust vs Python

  • Python: 因為 Python 是動態語言,函數可以接受任何類型的參數,無需明確宣告類型,這樣雖然靈活但不一定安全。
  • Rust: 泛型讓 Rust 的函數能夠接受不同類型的資料,並保有資料類型安全,避免因不合適的類型而導致錯誤。

2. 泛型結構體

泛型結構體允許我們定義可以接受多種不同類型數據的結構體。這樣一來,我們就可以用同一個結構體來操作不同類型的資料,無需重複撰寫多個類型相似的結構體定義,這讓代碼更簡潔、更靈活。

什麼是泛型結構體?

泛型結構體是使用泛型類型參數的結構體。這些參數在結構體定義時被標記為泛型,並且在創建結構體的實例時由具體的類型取代。這樣的設計可以讓一個結構體適應多種類型,而不必為每個類型寫一個新的結構體。

泛型結構體的應用模板

泛型結構體的使用有一個通用的模板或公式,以下是基本的應用模板:

// 範例模板:定義一個使用泛型的結構體
struct StructName<T> {
    field1: T,
    field2: T,
    // ... 更多欄位
}

// 使用範例:建立泛型結構體的實例
fn main() {
    let instance = StructName { field1: value1, field2: value2 };
    // 使用結構體的欄位
}

這個模板拆解後的說明如下:

  • struct StructName<T>StructName 是結構體的名稱,<T> 宣告這個結構體使用泛型 T,這表示結構體的欄位可以接受任何類型。
  • field1: T, field2: Tfield1field2 是結構體的欄位,T 表示這些欄位的類型是由泛型 T 定義的,可以是任何具體的類型,如整數、字串等。
  • let instance = StructName { field1: value1, field2: value2 };:建立結構體的實例時,會根據給定的值自動推斷出使用的具體類型。

使用泛型結構體的詳細說明

讓我們以 Point<T> 結構體為例,進一步解析如何使用泛型結構體。

// 定義一個泛型結構體
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    // 使用 i32 整數類型的 Point
    let int_point = Point { x: 5, y: 10 };
    
    // 使用 f64 浮點數類型的 Point
    let float_point = Point { x: 1.2, y: 3.4 };

    println!("整數點: ({}, {}), 浮點數點: ({}, {})", int_point.x, int_point.y, float_point.x, float_point.y);
}

範例解析:

  1. 結構體定義:struct Point<T>

    • 在這裡,<T> 宣告 Point 結構體使用泛型類型 TT 可以代表任何類型,如整數 (i32)、浮點數 (f64)、字串 (String) 等。
    • 結構體中的欄位 xy 都使用類型 T,表示它們的類型會根據使用情況進行替換。
  2. 創建實例:let int_point = Point { x: 5, y: 10 };

    • 這裡的 Point { x: 5, y: 10 } 創建了一個 Point 實例,因為傳入的是整數 510,所以這個 Point 會被推斷為 Point<i32>
  3. 使用其他類型:let float_point = Point { x: 1.2, y: 3.4 };

    • 同樣的,當傳入浮點數 1.23.4 時,這個 Point 實例會被推斷為 Point<f64>,表明我們不需要重新定義結構體就能操作不同類型的資料。

泛型結構體的延伸應用

除了基本的單一類型泛型,結構體也可以同時使用多個泛型,這樣每個欄位可以接受不同的類型。以下是更進階的泛型結構體應用:

// 定義一個使用兩個泛型的結構體
struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let mixed_point = Point { x: 5, y: 3.2 }; // x 是 i32,y 是 f64
    println!("混合類型點: ({}, {})", mixed_point.x, mixed_point.y);
}

範例解析:

  • 結構體定義:struct Point<T, U>
    使用兩個泛型 TU,讓結構體的不同欄位可以有不同的類型,例如 x 是整數,y 是浮點數。

  • 創建實例:let mixed_point = Point { x: 5, y: 3.2 };
    這裡的 Point 實例同時接收兩種不同類型的數據,xi32yf64

這種多泛型的結構體設計進一步提升了代碼的靈活性,允許開發者在需要時組合多種類型的資料,從而使程式具備更高的適應性。

3. 泛型方法

在 Rust 中,除了在函數和結構體中使用泛型,還可以在結構體的方法中使用泛型。這樣的設計可以讓方法同樣適應不同類型的資料,增加了代碼的靈活性和重用性。這對於開發需要處理多種不同類型數據的程式非常有幫助。

泛型方法的應用模板

泛型方法的使用也有一個基本的模板或公式,以下是泛型方法的應用模板:

// 範例模板:定義一個使用泛型的結構體方法
impl<T> StructName<T> {
    fn method_name(&self) -> &T {
        // 方法邏輯
        &self.field
    }
}

這個模板拆解後的說明如下:

  • impl<T> StructName<T>:定義 StructName 結構體的實作區塊,<T> 宣告這個實作區塊會使用泛型 T
  • fn method_name(&self) -> &T:宣告方法名稱 method_name&self 是方法的第一個參數,表示方法是作用於結構體的實例上的。-> &T 表示這個方法的返回值是對泛型 T 類型資料的引用。
  • &self.field:方法的內部邏輯,這裡通常是操作結構體內部的欄位 field

使用泛型方法的詳細說明

讓我們使用 Point<T> 結構體來展示如何定義和使用泛型方法。

// 定義一個泛型結構體
struct Point<T> {
    x: T,
    y: T,
}

// 為泛型結構體實作一個泛型方法
impl<T> Point<T> {
    fn get_x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let point = Point { x: 10, y: 20 };  // 使用 i32 類型的 Point
    println!("X 值: {}", point.get_x()); // 取得 x 的值
}

範例解析:

  1. 結構體定義:struct Point<T>

    • 這裡的 Point<T> 是一個使用泛型 T 的結構體,T 可以代表任意的資料類型。
  2. 方法實作:impl<T> Point<T>

    • 使用 impl<T> 開始實作區塊,表明接下來的內容是針對泛型結構體 Point<T> 的方法實作。
  3. 定義方法:fn get_x(&self) -> &T

    • get_x 方法返回結構體中 x 欄位的引用,該欄位的類型是泛型 T&self 表示這個方法作用於 Point 實例上。
  4. 方法調用:point.get_x()

    • main 函數中,我們創建了一個 Point<i32> 的實例並調用了 get_x 方法,取得 x 的值。

泛型方法的擴展應用

除了單一泛型的使用,我們也可以在方法中使用多個泛型類型,以處理更為複雜的場景。例如,我們可以讓方法同時接受不同類型的參數,並返回不同類型的結果。

進階範例:多泛型方法的應用

// 定義一個泛型結構體
struct Point<T, U> {
    x: T,
    y: U,
}

// 實作多泛型方法
impl<T, U> Point<T, U> {
    fn swap_types<V, W>(self, new_x: V, new_y: W) -> Point<V, W> {
        Point { x: new_x, y: new_y }
    }
}

fn main() {
    let point = Point { x: 5, y: 3.5 }; // x 是 i32, y 是 f64
    let new_point = point.swap_types("A", "B"); // 交換類型為 &str
    println!("新點: ({}, {})", new_point.x, new_point.y);
}

範例解析:

  1. 結構體定義:struct Point<T, U>

    • 使用兩個泛型 TU 來定義結構體,使得 xy 欄位可以接受不同的資料類型。
  2. 方法實作:impl<T, U> Point<T, U>

    • 使用 impl<T, U> 開始實作區塊,表明接下來的內容是針對泛型結構體 Point<T, U> 的方法實作。
  3. 定義多泛型方法:fn swap_types<V, W>(self, new_x: V, new_y: W) -> Point<V, W>

    • swap_types 方法使用額外的泛型 VW,允許 new_xnew_y 使用不同的類型。返回值是 Point<V, W>,即新的泛型結構體。
  4. 方法調用:point.swap_types("A", "B")

    • Point<i32, f64> 轉換為 Point<&str, &str>,展示了如何透過多泛型的設計來靈活處理不同類型的資料。

二、什麼是特徵(Traits)?

特徵(Traits)是 Rust 中用來定義一組共享行為的工具。簡單來說,特徵就像是**「行為契約」**,當一個類型(例如結構體,但不僅限於結構體)想要擁有某些特定的行為時,它必須實作這些行為。實作的意思是,類型需要具體定義這些行為是怎麼運作的。這就像簽訂合約後,你必須履行合約中的義務一樣。

特徵就像是駕照,只有拿到駕照的人(類型)才能開車(使用這些行為)。要開車得先學會開車技巧,也就是「實作」這些行為。

具體來說,特徵讓你可以在程式碼中定義某些「必須有的功能」。如果一個結構體想要實作這些功能,就必須具體寫出它們是如何實現的。這種設計方式讓代碼更加結構化,並提升了程式的安全性和可維護性。

特徵的應用模板

以下是我們在 Rust 當中使用特徵在結構體方法當中的模板

// 定義一個資料類型,假設為結構體
struct StructName {
    // 結構體欄位
}

// 定義一個特徵,包含需要實作的方法
trait TraitName {
    fn method_name(&self); // 特徵條件名稱
    // 這裡的函數,可被視為"特徵所要求的條件"
}

// 為結構體實作特徵
impl TraitName for StructName {
    fn method_name(&self) {
        // 檢測是否達到條件的具體實作
    }
}
  1. 首先需要先定義一個結構體
  2. 再接著定義一個特徵名稱,該特徵之下需要加入特徵所要求的條件跟名稱
  3. 清楚定義某個特徵之於指定結構體而言的檢測方法,使用 impl 特徵名稱 for 結構體名稱 來定義,並且在其中清楚定義如何使該結構體可以進行特徵檢測的動作。

以上是大致上的一個應用方式,接下來我們來看實際範例。

1. 定義與實作特徵

特徵的概念就像是設計一組「行為標準」,並要求符合這些標準的類型才能執行特定的操作。我們舉一個例子,假設我們想要將「跑百米能在11秒內的人」定義為一個特徵,則這個特徵的定義就應該要包含對於「跑步速度」如何衡量的這個能力。如果某個類型的資料(例如:某個選手)想要參與特定的方法(例如:加入一場比賽),那麼這個類型就必須符合這個「跑步速度」特徵的標準,才能通過資格使用該方法。

這就像我們在程式中使用特徵來規範哪些類型可以使用某些功能。具體來說:

  1. 定義特徵:我們可以定義一個特徵,比如 CanRunFast,它要求類型必須能執行 run() 方法。
  2. 實作特徵:只有那些實作了這個 CanRunFast 特徵的類型才能被認為具備這個能力。
  3. 使用限制:當我們在方法中使用這個特徵時,只有符合條件的類型才會被允許調用這些方法。

以下是用 Rust 來具體化這個例子的方式:

// 結構體 Sprinter 代表一位跑步選手
struct Sprinter {
    name: String,
    speed: f32,
}

// 定義一個特徵 CanRunFast,要求必須實作 run 方法
trait CanRunFast {
    fn run(&self) -> String;
}

// 為 Sprinter 實作 CanRunFast 特徵
impl CanRunFast for Sprinter {
    fn run(&self) -> String {
        if self.speed <= 12.0 {
            format!("{} 跑百米速度 {} 秒!", self.name, self.speed)
        } else {
            format!("{} 速度還不夠快。", self.name)
        }
    }
}

// 只允許符合 CanRunFast 特徵的類型使用的函數
fn compete<T: CanRunFast>(runner: T) {
    println!("{}", runner.run());
}

fn main() {
    let fast_runner = Sprinter { name: String::from("Usain"), speed: 10.58 };
    let slow_runner = Sprinter { name: String::from("John"), speed: 12.1 };

    compete(fast_runner); // 輸出:Usain 跑百米速度 10.58 秒!
    compete(slow_runner); // 輸出:John 速度還不夠快。
}

說明:

  1. 定義特徵 CanRunFast:這個特徵要求結構體必須有 run() 方法。

  2. 實作特徵Sprinter 結構體實作了 CanRunFast,並具體定義了 run() 方法,描述選手是否符合快速跑者的標準。

  3. 方法限制:函數 compete() 只有實作了 CanRunFast 特徵的類型才能使用,這樣就確保了只有符合要求的選手才能參加比賽。

這樣的設計讓我們能夠清楚規範哪些類型可以做什麼,避免錯誤地使用不符合條件的類型,並讓程式的邏輯更具結構性。

2. 特徵物件與動態分派

在 Rust 中,特徵物件可以用來將特徵作為指標使用,使得程式可以在運行時動態選擇要執行的特定方法。這與靜態分派(編譯時確定方法的調用)不同,特徵物件是通過 dyn 關鍵字來進行動態分派的,這使得程式能夠更加靈活,但也會帶來一些性能上的開銷。

以下我們會繼續沿用前面的 CanRunFast 特徵的例子,這次我們不僅用靜態分派來決定選手能否參賽,還能讓不同的選手在運行時根據各自的特性來做不同的描述。

// 定義一個特徵 CanRunFast,要求必須實作 run 方法
trait CanRunFast {
    fn run(&self) -> String;
}

// 結構體 Sprinter 代表一位跑步選手
struct Sprinter {
    name: String,
    speed: f32,
}

// 結構體 Marathoner 代表一位馬拉松選手
struct Marathoner {
    name: String,
    endurance: u32,
}

// 為 Sprinter 實作 CanRunFast 特徵
impl CanRunFast for Sprinter {
    fn run(&self) -> String {
        format!("{} 跑百米速度 {} 秒!", self.name, self.speed)
    }
}

// 為 Marathoner 實作 CanRunFast 特徵
impl CanRunFast for Marathoner {
    fn run(&self) -> String {
        format!("{} 具有很強的耐力,能夠持續跑好幾個小時!", self.name)
    }
}

// 使用特徵物件動態選擇符合 CanRunFast 特徵的物件
fn describe_runner(runner: &dyn CanRunFast) {
    println!("{}", runner.run());
}

fn main() {
    let sprinter = Sprinter { name: String::from("Usain"), speed: 9.58 };
    let marathoner = Marathoner { name: String::from("Eliud"), endurance: 100 };

    // 動態選擇要使用的方法,根據物件類型呼叫各自的實作
    describe_runner(&sprinter);  // 輸出:Usain 跑百米速度 9.58 秒!
    describe_runner(&marathoner);  // 輸出:Eliud 具有很強的耐力,能夠持續跑好幾個小時!
}

說明:

  1. 延續使用 CanRunFast 特徵:我們定義的特徵要求 run() 方法,並將這個特徵實作在不同的結構體上。

  2. 新增結構體 Marathoner:為了展示不同特徵物件的應用,我們新增了一個代表馬拉松選手的結構體。

  3. 為不同選手實作各自的 run() 方法SprinterMarathoner 都實作了 CanRunFast 特徵,但各自的 run() 方法有所不同,這體現了它們在賽場上的差異。

  4. 使用 &dyn CanRunFast 的動態分派:透過特徵物件 &dyn CanRunFast,我們在運行時選擇調用 SprinterMarathoner 各自的 run() 方法。這讓我們可以對不同的選手進行描述,而不需要在編譯時期決定是哪一種選手。

特徵物件的優勢與限制

  • 優點:透過動態分派,我們可以更靈活地處理不同類型的物件,尤其是在需要根據運行時的狀況選擇行為時,特徵物件提供了很大的方便性。
  • 缺點:因為是運行時決定的分派,特徵物件的調用會有一些額外的性能開銷,這是因為它需要在運行時檢查並決定具體的行為。

三、泛型與特徵物件的實戰應用:簡易訊息展示系統

現在,我們來設計一個簡易的「訊息展示系統」,這個系統可以接收不同類型的訊息(例如:文字訊息、圖片訊息),並且根據訊息的類型選擇適合的展示方式。透過這個例子,我們將結合泛型結構體與特徵物件,來實現一個靈活的訊息處理系統。

實戰範例:結合泛型與特徵物件的訊息展示

以下是具體的 Rust 實作,展示如何結合泛型結構體與特徵物件來處理不同類型的訊息。

// 定義一個文字訊息結構體 TextMessage
struct TextMessage {
    content: String,
}

// 定義一個圖片訊息結構體 ImageMessage
struct ImageMessage {
    url: String,
}

// 定義一個泛型結構體 MessageBox,用於裝載不同類型的訊息
struct MessageBox<T> {
    message: T,
}

// 為泛型結構體 MessageBox 實作新建方法
impl<T> MessageBox<T> {
    fn new(message: T) -> Self {
        MessageBox { message }
    }
}

// 定義一個特徵 Displayable,要求實作 display 方法來展示訊息
trait Displayable {
    fn display(&self);
}

// 為 TextMessage 實作 Displayable 特徵
impl Displayable for TextMessage {
    fn display(&self) {
        println!("文字訊息: {}", self.content);
    }
}


// 為 ImageMessage 實作 Displayable 特徵
impl Displayable for ImageMessage {
    fn display(&self) {
        println!("圖片訊息網址: {}", self.url);
    }
}


// 定義一個展示訊息的函數,接收任何符合 Displayable 特徵的物件
fn show_message<T: Displayable>(message: T) {
    message.display();
}

fn main() {
    // 創建一個文字訊息
    let text = TextMessage {
        content: String::from("這是一則文字訊息!"),
    };

    // 創建一個圖片訊息
    let image = ImageMessage {
        url: String::from("http://example.com/image.png"),
    };

    // 使用泛型結構體將訊息包裝進 MessageBox 中
    let text_box = MessageBox::new(text);
    let image_box = MessageBox::new(image);

    // 顯示訊息內容
    show_message(text_box.message); // 輸出: 文字訊息: 這是一則文字訊息!
    show_message(image_box.message); // 輸出: 圖片訊息網址: http://example.com/image.png
}

範例說明:

  1. 定義訊息類型與展示行為:

    • 我們定義了兩種類型的訊息:TextMessage 代表文字訊息,ImageMessage 代表圖片訊息。這些訊息結構體各自實作了 Displayable 特徵,來定義如何展示這些訊息。
  2. 泛型結構體 MessageBox<T> 的使用:

    • 泛型結構體 MessageBox<T> 用來包裝任意類型的訊息,使得我們可以根據不同的需求來包裝不同類型的資料。
  3. 使用特徵物件來選擇展示行為:

    • 函數 show_message() 接收任何實作了 Displayable 特徵的物件,並動態選擇正確的 display() 方法進行展示。這讓我們可以根據不同的訊息類型自動選擇合適的展示方式。
  4. 運行時的行為選擇:

    • main 函數中,我們創建了文字訊息和圖片訊息,並將它們包裝進 MessageBox。使用 show_message() 函數時,程式會根據訊息類型動態選擇對應的展示方法,達到靈活處理的效果。

與前面範例的連接:

這個例子展示了如何結合泛型結構體與特徵物件,使得我們可以靈活地處理多種類型的資料。與前面的範例類似,我們在這裡利用了泛型來處理不同類型的訊息,並透過特徵物件來實現多樣化的展示行為。這樣的設計讓我們的程式碼不僅更加靈活,也更容易擴展,未來如果需要支持新的訊息類型,只需新增新的結構體並實作 Displayable 特徵即可。


四、常見泛型範例與特徵應用

常見的特徵與應用範例

  1. PartialOrd:用於實現部分排序,允許比較大小。在泛型函數中,這個特徵能讓你比較不同類型的數據,例如找到最大值。

    fn largest<T: PartialOrd>(list: &[T]) -> &T {
        let mut largest = &list[0];
        for item in list {
            if item > largest {
                largest = item;
            }
        }
        largest
    }
    
  2. Clone:提供資料的深複製功能,讓你能夠創建一個資料的複本而不影響原資料。常見於需要複製資料但不想修改原物件的情境。

    #[derive(Clone)]
    struct Point<T> {
        x: T,
        y: T,
    }
    
    fn duplicate_point<T: Clone>(point: &Point<T>) -> Point<T> {
        point.clone()
    }
    
  3. Debug:這個特徵用來讓物件可以使用 {:?} 輸出,用於除錯和觀察內部狀態。

    #[derive(Debug)]
    struct Person {
        name: String,
        age: u8,
    }
    
    fn print_debug_info(person: &Person) {
        println!("{:?}", person);
    }
    
  4. Display:提供將物件以人類可讀的格式輸出的功能,讓你可以自訂物件的顯示方式。

    use std::fmt;
    
    struct Point {
        x: i32,
        y: i32,
    }
    
    impl fmt::Display for Point {
        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
            write!(f, "({}, {})", self.x, self.y)
        }
    }
    

透過這些範例,我們可以看到泛型和特徵如何提升 Rust 代碼的靈活性和重用性,讓開發者在不同類型之間輕鬆地應用相同的邏輯,而無需重複撰寫相似的代碼。


五、特徵可應用範圍

Rust 中的特徵(Traits)是非常強大的工具,它們不僅僅可以被應用於結構體(structs),還可以廣泛應用於基本資料類型、枚舉、甚至是函數等。了解特徵的可應用範圍能夠幫助開發者靈活地運用這些行為契約,從而提升代碼的靈活性和重用性。以下將介紹特徵在不同場景中的應用:

1. 結構體(Structs)

特徵最常被用於結構體,透過實作特徵,結構體可以具備更多的行為與功能。這些特徵可以是標準庫中的,也可以是自定義的特徵。這部分我們上面已經有相關範例,接下來我們展示一下不同的資料類型的特徵應用。

2. 枚舉(Enums)

枚舉在 Rust 中是一種強大且靈活的資料類型。特徵也可以被實作於枚舉上,使得每個枚舉變體(variants)都能夠具備特徵所要求的行為。

範例:為枚舉實作特徵

enum Shape {
    Circle(f64),
    Square(f64),
}

trait Area {
    fn area(&self) -> f64;
}

impl Area for Shape {
    fn area(&self) -> f64 {
        match *self {
            Shape::Circle(radius) => 3.14 * radius * radius,
            Shape::Square(side) => side * side,
        }
    }
}

fn main() {
    let circle = Shape::Circle(5.0);
    let square = Shape::Square(4.0);

    println!("圓形面積: {}", circle.area());
    println!("正方形面積: {}", square.area());
}

這裡的 Shape 枚舉實作了 Area 特徵,讓每個變體可以根據自身的定義來計算面積。

3. 基本資料類型(Primitive Types)

基本資料類型,例如整數、浮點數、字串等,也能實作自訂的特徵。這讓我們可以為 Rust 的內建類型擴展行為,使其具備更多自定義的功能。

範例:為基本資料類型實作特徵

trait Double {
    fn double(&self) -> Self;
}

impl Double for i32 {
    fn double(&self) -> Self {
        self * 2
    }
}

fn main() {
    let num = 10;
    println!("{} 的兩倍是 {}", num, num.double());
}

在這個範例中,我們為 i32 類型實作了一個自訂的 Double 特徵,讓 i32 可以透過 double() 方法進行倍數運算。

4. 函數(Functions)

特徵也可以應用於函數,使得函數本身成為可以作為參數傳遞或作為返回值的第一級物件。這在高階函數的應用中尤其常見,例如在閉包與函數指標中。

範例:為函數實作特徵

trait Execute {
    fn execute(&self);
}

impl<F: Fn()> Execute for F {
    fn execute(&self) {
        self();
    }
}

fn main() {
    let print_message = || println!("執行函數!");
    print_message.execute();
}

在這個範例中,Execute 特徵被實作於任何符合 Fn() 的函數或閉包上,使得它們能夠呼叫 execute() 方法執行自身。

5. 特徵的多重實作與自動衍生

Rust 支持多重特徵實作,即同一個類型可以同時實作多個特徵,並且 Rust 內建了許多自動衍生的特徵,例如 DebugClone 等。這些特徵可以透過 #[derive()] 直接為結構體或枚舉自動添加實作,讓開發者省去手動定義的麻煩。

範例:自動衍生特徵

#[derive(Debug, Clone)]
struct Book {
    title: String,
    author: String,
}

fn main() {
    let book1 = Book {
        title: String::from("Rust 程式設計"),
        author: String::from("Rustacean"),
    };
    let book2 = book1.clone();

    println!("{:?}", book1);
    println!("{:?}", book2);
}

在這個例子中,透過衍生 DebugClone 特徵,Book 結構體可以方便地被複製和除錯輸出。

6. 特徵物件與動態分派

除了靜態實作特徵,Rust 也支持使用特徵物件(dyn Trait)進行動態分派。這讓我們能夠在運行時選擇合適的行為,提升程式的彈性。

範例:特徵物件的動態分派

trait Speak {
    fn speak(&self);
}

struct Dog;
struct Cat;

impl Speak for Dog {
    fn speak(&self) {
        println!("汪汪!");
    }
}

impl Speak for Cat {
    fn speak(&self) {
        println!("喵喵!");
    }
}

fn animal_speak(animal: &dyn Speak) {
    animal.speak();
}

fn main() {
    let dog = Dog;
    let cat = Cat;

    animal_speak(&dog); // 輸出: 汪汪!
    animal_speak(&cat); // 輸出: 喵喵!
}

在這個範例中,我們使用特徵物件來實現動態分派,根據不同的物件選擇正確的 speak() 行為。


特徵的應用範圍非常廣泛,不僅可以增強結構體和枚舉的功能,也能夠為基本資料類型添加自定義行為,甚至是函數本身。這些特徵讓 Rust 的程式設計更加靈活與彈性,為開發者提供了強大的工具來組織和管理代碼,從而寫出更加清晰和易於維護的程式。透過特徵,我們可以構建出更具可讀性和擴展性的 Rust 代碼,為複雜應用奠定堅實的基礎。

六、大量使用泛型的優缺點

看到這邊,有Python開發經驗的人可能就會想說,那如果我全部都使用泛型作為資料類型定義,不就跟Python一樣每個變數、參數都可以使用通用的格式了嗎?因此我們就來分析一下大量使用泛型可能的優缺點吧:

在 Rust 程式中大量使用泛型來定義資料類型,讓程式碼看起來像 Python 一樣通用,的確能夠帶來靈活性,但這種做法也會帶來一些挑戰和潛在問題。以下是這種編碼風格的優缺點:

優點:

  1. 提升程式的靈活性:

    • 泛型讓函數、結構體和方法能夠接受多種類型,減少了重複撰寫不同類型的程式碼,使得程式更具彈性,可以在不修改原有程式碼的情況下處理更多的資料類型。
  2. 提高代碼的重用性:

    • 透過使用泛型,開發者可以將邏輯抽象化,適用於各種資料類型,減少重複代碼的撰寫,增加代碼的可維護性和重用性。
  3. 增強類型安全:

    • 雖然泛型讓程式看起來靈活,Rust 仍然透過編譯時期的類型檢查,確保泛型所接受的類型符合要求的特徵(Traits),這樣能避免因類型不匹配導致的錯誤。
  4. 易於擴展新功能:

    • 使用泛型的設計使得程式架構更具通用性,當需要擴展新的功能或支持新的資料類型時,只需實作相關特徵或滿足泛型需求,無需大幅度更改現有程式碼。

缺點:

  1. 增加編譯時間:

    • Rust 的泛型在編譯時會根據不同的具體類型生成對應的代碼(稱為單態化,Monomorphization),這可能導致編譯時間變長,尤其是在大量使用泛型的情況下,編譯器需要處理更多的型別推導和檢查。
  2. 難以除錯和閱讀:

    • 泛型過度使用會讓程式碼變得不易理解,尤其是當泛型引入複雜的特徵約束或多重泛型參數時,對於新手或不熟悉泛型概念的開發者而言,理解代碼的邏輯會變得困難。
  3. 潛在的性能問題:

    • 雖然 Rust 透過單態化生成具體類型的代碼來維持效能,但使用泛型時仍有可能引入間接的效能開銷,例如多態行為的動態分派(如果使用了特徵物件),這與靜態類型相比可能會略慢。
  4. 限制型別特定操作:

    • 泛型雖然讓程式碼更具通用性,但它也限制了直接使用某些類型特定的操作。例如,你無法在泛型中直接使用需要特定類型的操作(如加法、比較等),除非透過特徵約束進行定義,這可能需要額外的程式碼撰寫。
  5. 複雜的特徵約束:

    • 當泛型需要特定的行為時,必須引入適當的特徵約束(如 T: Display + Clone),這會使函數或結構體的定義變得冗長和複雜,增加了維護的負擔。

因此,雖然大量使用泛型讓 Rust 程式碼更靈活且類似於 Python 的動態特性,但這種做法需要權衡代碼可讀性、編譯時間和效能的潛在影響。雖然泛型提供了強大的靈活性,過度依賴可能會使程式碼變得難以管理,因此應根據實際需求適度使用泛型,同時確保代碼的可讀性和效能不受到過多影響。

七、總結

泛型與特徵物件是 Rust 讓代碼保持靈活、重用且類型安全的重要工具。對於熟悉 Python 的開發者來說,這些工具讓你不再局限於單一類型,可以設計出更通用、易於擴展的程式碼。以下是本篇文章的重點:

  1. 泛型:提升了 Rust 代碼的彈性,避免重複代碼,類似於 Python 的靈活性。
  2. 特徵與特徵物件:提供靜態與動態分派的多型能力,讓代碼更具彈性。
  3. 實戰應用:結合泛型與特徵設計出可重用的結構體與方法,達到代碼的最佳化。

學習如何善用這些工具,你將能寫出更強大、更具彈性的 Rust 代碼,讓開發效率大幅提升!


上一篇
[Day 15] 並行編程:Rust 的 threads 與 async
下一篇
[Day 17] 建立你的第一個 Rust CLI 應用程式
系列文
從 Python 開發者的角度學習 Rust —— 從語法基礎到實戰應用30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言